Sizing¶
Size a solar heating system - let the optimizer decide equipment sizes.
This notebook introduces:
- InvestParameters: Define investment decisions with size bounds and costs
- Investment costs: Fixed costs and size-dependent costs
- Optimal sizing: Let the optimizer find the best equipment sizes
- Trade-off analysis: Balance investment vs. operating costs
Setup¶
In [1]:
Copied!
import numpy as np
import pandas as pd
import plotly.express as px
import xarray as xr
import flixopt as fx
fx.CONFIG.notebook()
import numpy as np import pandas as pd import plotly.express as px import xarray as xr import flixopt as fx fx.CONFIG.notebook()
Out[1]:
flixopt.config.CONFIG
System Description¶
The swimming pool heating system:
- Solar collectors: Convert solar radiation to heat (size to be optimized)
- Gas boiler: Backup heating when solar is insufficient (existing, 200 kW)
- Buffer tank: Store excess solar heat (size to be optimized)
- Pool: Constant heat demand of 150 kW during operating hours
☀️ Solar ──► [Heat] ◄── Boiler ◄── [Gas]
│
▼
Buffer Tank
│
▼
Pool 🏊
Define Time Horizon and Profiles¶
We model one representative summer week:
In [2]:
Copied!
# One week in summer, hourly
timesteps = pd.date_range('2024-07-15', periods=168, freq='h')
hours = np.arange(168)
hour_of_day = hours % 24
# Solar radiation profile (kW/m² equivalent, simplified)
# Peak around noon, zero at night
solar_profile = np.maximum(0, np.sin((hour_of_day - 6) * np.pi / 12)) * 0.8
solar_profile = np.where((hour_of_day >= 6) & (hour_of_day <= 20), solar_profile, 0)
# Add some cloud variation
np.random.seed(42)
cloud_factor = np.random.uniform(0.6, 1.0, len(timesteps))
solar_profile = solar_profile * cloud_factor
# Pool operates 8am-10pm, constant demand when open
pool_demand = np.where((hour_of_day >= 8) & (hour_of_day <= 22), 150, 50) # kW
print(f'Peak solar: {solar_profile.max():.2f} kW/kW_installed')
print(f'Pool demand: {pool_demand.max():.0f} kW (open), {pool_demand.min():.0f} kW (closed)')
# One week in summer, hourly timesteps = pd.date_range('2024-07-15', periods=168, freq='h') hours = np.arange(168) hour_of_day = hours % 24 # Solar radiation profile (kW/m² equivalent, simplified) # Peak around noon, zero at night solar_profile = np.maximum(0, np.sin((hour_of_day - 6) * np.pi / 12)) * 0.8 solar_profile = np.where((hour_of_day >= 6) & (hour_of_day <= 20), solar_profile, 0) # Add some cloud variation np.random.seed(42) cloud_factor = np.random.uniform(0.6, 1.0, len(timesteps)) solar_profile = solar_profile * cloud_factor # Pool operates 8am-10pm, constant demand when open pool_demand = np.where((hour_of_day >= 8) & (hour_of_day <= 22), 150, 50) # kW print(f'Peak solar: {solar_profile.max():.2f} kW/kW_installed') print(f'Pool demand: {pool_demand.max():.0f} kW (open), {pool_demand.min():.0f} kW (closed)')
Peak solar: 0.76 kW/kW_installed Pool demand: 150 kW (open), 50 kW (closed)
In [3]:
Copied!
# Visualize profiles with plotly - using xarray and faceting
profiles = xr.Dataset(
{
'Solar Profile [kW/kW]': xr.DataArray(solar_profile, dims=['time'], coords={'time': timesteps}),
'Pool Demand [kW]': xr.DataArray(pool_demand, dims=['time'], coords={'time': timesteps}),
}
)
# Convert to long format for faceting
df = profiles.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value')
fig = px.line(df, x='time', y='value', facet_col='variable', height=300)
fig.update_yaxes(matches=None, showticklabels=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig
# Visualize profiles with plotly - using xarray and faceting profiles = xr.Dataset( { 'Solar Profile [kW/kW]': xr.DataArray(solar_profile, dims=['time'], coords={'time': timesteps}), 'Pool Demand [kW]': xr.DataArray(pool_demand, dims=['time'], coords={'time': timesteps}), } ) # Convert to long format for faceting df = profiles.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value') fig = px.line(df, x='time', y='value', facet_col='variable', height=300) fig.update_yaxes(matches=None, showticklabels=True) fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1])) fig
Define Costs¶
Investment costs are annualized (€/year) to compare with operating costs:
In [4]:
Copied!
# Cost parameters
GAS_PRICE = 0.12 # €/kWh - high gas price makes solar attractive
# Solar collectors: 400 €/kW installed, 20-year lifetime → ~25 €/kW/year annualized
# (simplified, real calculation would include interest rate)
SOLAR_COST_PER_KW = 20 # €/kW/year
# Buffer tank: 50 €/kWh capacity, 30-year lifetime → ~2 €/kWh/year
TANK_COST_PER_KWH = 1.5 # €/kWh/year
# Scale factor: We model 1 week, but costs are annual
# So we scale investment costs to weekly equivalent
WEEKS_PER_YEAR = 52
SOLAR_COST_WEEKLY = SOLAR_COST_PER_KW / WEEKS_PER_YEAR
TANK_COST_WEEKLY = TANK_COST_PER_KWH / WEEKS_PER_YEAR
print(f'Solar cost: {SOLAR_COST_WEEKLY:.3f} €/kW/week')
print(f'Tank cost: {TANK_COST_WEEKLY:.4f} €/kWh/week')
# Cost parameters GAS_PRICE = 0.12 # €/kWh - high gas price makes solar attractive # Solar collectors: 400 €/kW installed, 20-year lifetime → ~25 €/kW/year annualized # (simplified, real calculation would include interest rate) SOLAR_COST_PER_KW = 20 # €/kW/year # Buffer tank: 50 €/kWh capacity, 30-year lifetime → ~2 €/kWh/year TANK_COST_PER_KWH = 1.5 # €/kWh/year # Scale factor: We model 1 week, but costs are annual # So we scale investment costs to weekly equivalent WEEKS_PER_YEAR = 52 SOLAR_COST_WEEKLY = SOLAR_COST_PER_KW / WEEKS_PER_YEAR TANK_COST_WEEKLY = TANK_COST_PER_KWH / WEEKS_PER_YEAR print(f'Solar cost: {SOLAR_COST_WEEKLY:.3f} €/kW/week') print(f'Tank cost: {TANK_COST_WEEKLY:.4f} €/kWh/week')
Solar cost: 0.385 €/kW/week Tank cost: 0.0288 €/kWh/week
Build the System with Investment Options¶
Use InvestParameters to define which sizes should be optimized:
In [5]:
Copied!
flow_system = fx.FlowSystem(timesteps)
flow_system.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('heat', '#e74c3c', 'kW'),
)
flow_system.add_elements(
# === Buses ===
fx.Bus('Heat', carrier='heat'),
fx.Bus('Gas', carrier='gas'),
# === Effects ===
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
# === Gas Supply ===
fx.Source(
'GasGrid',
outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=GAS_PRICE)],
),
# === Gas Boiler (existing, fixed size) ===
fx.linear_converters.Boiler(
'GasBoiler',
thermal_efficiency=0.92,
thermal_flow=fx.Flow('Heat', bus='Heat', size=200), # 200 kW existing
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
# === Solar Collectors (size to be optimized) ===
fx.Source(
'SolarCollectors',
outputs=[
fx.Flow(
'Heat',
bus='Heat',
# Investment optimization: find optimal size between 0-500 kW
size=fx.InvestParameters(
minimum_size=0,
maximum_size=500,
effects_of_investment_per_size={'costs': SOLAR_COST_WEEKLY},
),
# Solar output depends on radiation profile
fixed_relative_profile=solar_profile,
)
],
),
# === Buffer Tank (size to be optimized) ===
fx.Storage(
'BufferTank',
# Investment optimization: find optimal capacity between 0-2000 kWh
capacity_in_flow_hours=fx.InvestParameters(
minimum_size=0,
maximum_size=2000,
effects_of_investment_per_size={'costs': TANK_COST_WEEKLY},
),
initial_charge_state=0,
eta_charge=0.95,
eta_discharge=0.95,
relative_loss_per_hour=0.01, # 1% loss per hour
charging=fx.Flow('Charge', bus='Heat', size=200),
discharging=fx.Flow('Discharge', bus='Heat', size=200),
),
# === Pool Heat Demand ===
fx.Sink(
'Pool',
inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=pool_demand)],
),
)
flow_system = fx.FlowSystem(timesteps) flow_system.add_carriers( fx.Carrier('gas', '#3498db', 'kW'), fx.Carrier('heat', '#e74c3c', 'kW'), ) flow_system.add_elements( # === Buses === fx.Bus('Heat', carrier='heat'), fx.Bus('Gas', carrier='gas'), # === Effects === fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True), # === Gas Supply === fx.Source( 'GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=GAS_PRICE)], ), # === Gas Boiler (existing, fixed size) === fx.linear_converters.Boiler( 'GasBoiler', thermal_efficiency=0.92, thermal_flow=fx.Flow('Heat', bus='Heat', size=200), # 200 kW existing fuel_flow=fx.Flow('Gas', bus='Gas'), ), # === Solar Collectors (size to be optimized) === fx.Source( 'SolarCollectors', outputs=[ fx.Flow( 'Heat', bus='Heat', # Investment optimization: find optimal size between 0-500 kW size=fx.InvestParameters( minimum_size=0, maximum_size=500, effects_of_investment_per_size={'costs': SOLAR_COST_WEEKLY}, ), # Solar output depends on radiation profile fixed_relative_profile=solar_profile, ) ], ), # === Buffer Tank (size to be optimized) === fx.Storage( 'BufferTank', # Investment optimization: find optimal capacity between 0-2000 kWh capacity_in_flow_hours=fx.InvestParameters( minimum_size=0, maximum_size=2000, effects_of_investment_per_size={'costs': TANK_COST_WEEKLY}, ), initial_charge_state=0, eta_charge=0.95, eta_discharge=0.95, relative_loss_per_hour=0.01, # 1% loss per hour charging=fx.Flow('Charge', bus='Heat', size=200), discharging=fx.Flow('Discharge', bus='Heat', size=200), ), # === Pool Heat Demand === fx.Sink( 'Pool', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=pool_demand)], ), )
Run Optimization¶
In [6]:
Copied!
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms
MIP linopy-problem-__3fw0hg has 2712 rows; 2374 cols; 7842 nonzeros; 338 integer variables (338 binary)
Coefficient ranges:
Matrix [1e-05, 2e+03]
Cost [1e+00, 1e+00]
Bound [3e-14, 2e+03]
RHS [1e+00, 1e+00]
WARNING: Problem has some excessively small column bounds
Presolving model
1348 rows, 1012 cols, 3276 nonzeros 0s
1013 rows, 677 cols, 3673 nonzeros 0s
1012 rows, 676 cols, 3672 nonzeros 0s
Presolve reductions: rows 1012(-1700); columns 676(-1698); nonzeros 3672(-4170)
Solving MIP model with:
1012 rows
676 cols (338 binary, 0 integer, 0 implied int., 338 continuous, 0 domain fixed)
3672 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 0.00% -inf 1690.076848 Large 0 0 0 0 0.0s
0 0 0 0.00% 782.9837399 1690.076848 53.67% 0 0 0 364 0.0s
C 0 0 0 0.00% 782.9837399 1488.93016 47.41% 1537 54 0 418 0.1s
L 0 0 0 0.00% 782.9837399 782.9837399 0.00% 2396 82 0 454 0.3s
1 0 1 100.00% 782.9837399 782.9837399 0.00% 2396 82 0 794 0.3s
Solving report
Model linopy-problem-__3fw0hg
Status Optimal
Primal bound 782.983739927
Dual bound 782.983739927
Gap 0% (tolerance: 1%)
P-D integral 0.151347858271
Solution status feasible
782.983739927 (objective)
0 (bound viol.)
3.46389583683e-14 (int. viol.)
0 (row viol.)
Timing 0.32
Max sub-MIP depth 1
Nodes 1
Repair LPs 0
LP iterations 794
0 (strong br.)
90 (separation)
340 (heuristics)
In [7]:
Copied!
solar_size = flow_system.statistics.sizes['SolarCollectors(Heat)'].item()
tank_size = flow_system.statistics.sizes['BufferTank'].item()
print('=== Optimal Investment Decisions ===')
print(f'Solar collectors: {solar_size:.1f} kW')
print(f'Buffer tank: {tank_size:.1f} kWh')
print(f'Tank-to-solar ratio: {tank_size / solar_size:.1f} kWh/kW' if solar_size > 0 else 'N/A')
solar_size = flow_system.statistics.sizes['SolarCollectors(Heat)'].item() tank_size = flow_system.statistics.sizes['BufferTank'].item() print('=== Optimal Investment Decisions ===') print(f'Solar collectors: {solar_size:.1f} kW') print(f'Buffer tank: {tank_size:.1f} kWh') print(f'Tank-to-solar ratio: {tank_size / solar_size:.1f} kWh/kW' if solar_size > 0 else 'N/A')
=== Optimal Investment Decisions === Solar collectors: 458.5 kW Buffer tank: 747.7 kWh Tank-to-solar ratio: 1.6 kWh/kW
Visualize Sizes¶
In [8]:
Copied!
flow_system.statistics.plot.sizes()
flow_system.statistics.plot.sizes()
Out[8]:
Cost Breakdown¶
In [9]:
Copied!
total_costs = flow_system.solution['costs'].item()
# Calculate cost components
solar_invest = solar_size * SOLAR_COST_WEEKLY
tank_invest = tank_size * TANK_COST_WEEKLY
gas_costs = total_costs - solar_invest - tank_invest
print('=== Weekly Cost Breakdown ===')
print(f'Solar investment: {solar_invest:.2f} € ({solar_invest / total_costs * 100:.1f}%)')
print(f'Tank investment: {tank_invest:.2f} € ({tank_invest / total_costs * 100:.1f}%)')
print(f'Gas operating: {gas_costs:.2f} € ({gas_costs / total_costs * 100:.1f}%)')
print('─────────────────────────────')
print(f'Total: {total_costs:.2f} €')
total_costs = flow_system.solution['costs'].item() # Calculate cost components solar_invest = solar_size * SOLAR_COST_WEEKLY tank_invest = tank_size * TANK_COST_WEEKLY gas_costs = total_costs - solar_invest - tank_invest print('=== Weekly Cost Breakdown ===') print(f'Solar investment: {solar_invest:.2f} € ({solar_invest / total_costs * 100:.1f}%)') print(f'Tank investment: {tank_invest:.2f} € ({tank_invest / total_costs * 100:.1f}%)') print(f'Gas operating: {gas_costs:.2f} € ({gas_costs / total_costs * 100:.1f}%)') print('─────────────────────────────') print(f'Total: {total_costs:.2f} €')
=== Weekly Cost Breakdown === Solar investment: 176.33 € (22.5%) Tank investment: 21.57 € (2.8%) Gas operating: 585.09 € (74.7%) ───────────────────────────── Total: 782.98 €
System Operation¶
In [10]:
Copied!
flow_system.statistics.plot.balance('Heat')
flow_system.statistics.plot.balance('Heat')
Out[10]:
In [11]:
Copied!
flow_system.statistics.plot.heatmap('SolarCollectors(Heat)')
flow_system.statistics.plot.heatmap('SolarCollectors(Heat)')
Out[11]:
In [12]:
Copied!
flow_system.statistics.plot.balance('BufferTank')
flow_system.statistics.plot.balance('BufferTank')
Out[12]:
Compare: What if No Solar?¶
Let's see how much the solar system saves:
In [13]:
Copied!
# Gas-only scenario
total_demand = pool_demand.sum()
gas_only_cost = total_demand / 0.92 * GAS_PRICE # All heat from gas boiler
savings = gas_only_cost - total_costs
savings_pct = savings / gas_only_cost * 100
print('=== Comparison with Gas-Only ===')
print(f'Gas-only cost: {gas_only_cost:.2f} €/week')
print(f'With solar: {total_costs:.2f} €/week')
print(f'Savings: {savings:.2f} €/week ({savings_pct:.1f}%)')
print(f'Annual savings: {savings * 52:.0f} €/year')
# Gas-only scenario total_demand = pool_demand.sum() gas_only_cost = total_demand / 0.92 * GAS_PRICE # All heat from gas boiler savings = gas_only_cost - total_costs savings_pct = savings / gas_only_cost * 100 print('=== Comparison with Gas-Only ===') print(f'Gas-only cost: {gas_only_cost:.2f} €/week') print(f'With solar: {total_costs:.2f} €/week') print(f'Savings: {savings:.2f} €/week ({savings_pct:.1f}%)') print(f'Annual savings: {savings * 52:.0f} €/year')
=== Comparison with Gas-Only === Gas-only cost: 2465.22 €/week With solar: 782.98 €/week Savings: 1682.23 €/week (68.2%) Annual savings: 87476 €/year
Energy Flow Sankey¶
A Sankey diagram visualizes the total energy flows through the system:
In [14]:
Copied!
flow_system.statistics.plot.sankey.flows()
flow_system.statistics.plot.sankey.flows()
Out[14]:
Key Concepts¶
InvestParameters Options¶
fx.InvestParameters(
minimum_size=0, # Lower bound (can be 0 for optional)
maximum_size=500, # Upper bound
fixed_size=100, # Or: fixed size (binary decision)
mandatory=True, # Force investment to happen
effects_of_investment={'costs': 1000}, # Fixed cost if invested
effects_of_investment_per_size={'costs': 25}, # Cost per unit size
)
Where to Use InvestParameters¶
- Flow.size: Optimize converter/source/sink capacity
- Storage.capacity_in_flow_hours: Optimize storage capacity
Summary¶
You learned how to:
- Define investment decisions with
InvestParameters - Set size bounds (minimum/maximum)
- Add investment costs (per-size and fixed)
- Access optimal sizes via
statistics.sizes - Visualize sizes with
statistics.plot.sizes()
Next Steps¶
- 04-operational-constraints: Add startup costs and minimum run times
- 05-multi-carrier-system: Model combined heat and power